package org.l.mybatis.core;

import java.lang.reflect.Array;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.ibatis.annotations.Arg;
import org.apache.ibatis.annotations.Case;
import org.apache.ibatis.annotations.ConstructorArgs;
import org.apache.ibatis.annotations.Lang;
import org.apache.ibatis.annotations.MapKey;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Options.FlushCachePolicy;
import org.apache.ibatis.annotations.Result;
import org.apache.ibatis.annotations.ResultMap;
import org.apache.ibatis.annotations.ResultType;
import org.apache.ibatis.annotations.Results;
import org.apache.ibatis.annotations.SelectKey;
import org.apache.ibatis.annotations.TypeDiscriminator;
import org.apache.ibatis.binding.MapperMethod.ParamMap;
import org.apache.ibatis.builder.BuilderException;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.cursor.Cursor;
import org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator;
import org.apache.ibatis.executor.keygen.KeyGenerator;
import org.apache.ibatis.executor.keygen.NoKeyGenerator;
import org.apache.ibatis.executor.keygen.SelectKeyGenerator;
import org.apache.ibatis.mapping.Discriminator;
import org.apache.ibatis.mapping.FetchType;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ResultFlag;
import org.apache.ibatis.mapping.ResultMapping;
import org.apache.ibatis.mapping.ResultSetType;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.mapping.StatementType;
import org.apache.ibatis.reflection.TypeParameterResolver;
import org.apache.ibatis.scripting.LanguageDriver;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeHandler;
import org.apache.ibatis.type.UnknownTypeHandler;
import org.l.mybatis.core.annotation.SqlsourceDelete;
import org.l.mybatis.core.annotation.SqlsourceInsert;
import org.l.mybatis.core.annotation.SqlsourceSelect;
import org.l.mybatis.core.annotation.SqlsourceUpdate;
import org.l.mybatis.core.exception.MethodDefineException;
import org.l.mybatis.core.sqlsource.SqlsourceBuilder;
import org.l.mybatis.core.sqlsource.SqlsourceBuilderFactory;

/**
 * 本类实现借鉴了类{@linkplain org.apache.ibatis.builder.annotation.MapperAnnotationBuilder
 * MapperAnnotationBuilder}，将其中许多方法直接引用过来<br />
 * Created by 严秋旺 on 2017/7/30.
 */
public class MapperSourceBuilder {
    private Configuration configuration;
    private MapperBuilderAssistant assistant;
    private Class<?> type;
    private SqlsourceBuilderFactory sqlsourceBuilderFactory;

    public MapperSourceBuilder(Configuration configuration, Class<?> type,
            SqlsourceBuilderFactory sqlsourceBuilderFactory) {
        String resource = type.getName().replace('.', '/') + ".java (best guess)";
        this.assistant = new MapperBuilderAssistant(configuration, resource);
        this.configuration = configuration;
        this.type = type;
        this.sqlsourceBuilderFactory = sqlsourceBuilderFactory;
    }

    public void parse() throws MethodDefineException {
        assistant.setCurrentNamespace(type.getName());

        for (Method method : type.getMethods()) {
            SqlsourceBuilder sqlsourceBuilder = getSqlSource(method);

            if (sqlsourceBuilder == null) {
                continue;
            }

            parseMethod(sqlsourceBuilder, method);
        }
    }

    public SqlsourceBuilder getSqlSource(Method method) throws MethodDefineException {
        SqlsourceSelect sqlsourceSelect = method.getAnnotation(SqlsourceSelect.class);

        if (sqlsourceSelect != null) {
            return sqlsourceBuilderFactory.createSelect(this.configuration, this.type, method, sqlsourceSelect);
        }

        SqlsourceInsert sqlsourceInsert = method.getAnnotation(SqlsourceInsert.class);

        if (sqlsourceInsert != null) {
            return sqlsourceBuilderFactory.createInsert(this.configuration, this.type, method, sqlsourceInsert);
        }

        SqlsourceUpdate sqlsourceUpdate = method.getAnnotation(SqlsourceUpdate.class);

        if (sqlsourceUpdate != null) {
            return sqlsourceBuilderFactory.createUpdate(configuration, type, method, sqlsourceUpdate);
        }

        SqlsourceDelete sqlsourceDelete = method.getAnnotation(SqlsourceDelete.class);

        if (sqlsourceDelete != null) {
            return sqlsourceBuilderFactory.createDelete(configuration, type, method, sqlsourceDelete);
        }

        return null;
    }

    private void parseMethod(SqlsourceBuilder sqlsourceBuilder, Method method) {
        Class<?> parameterTypeClass = getParameterType(method);
        final String mappedStatementId = type.getName() + "." + method.getName();
        LanguageDriver languageDriver = getLanguageDriver(method);
        Integer fetchSize = null;
        Integer timeout = null;
        StatementType statementType = StatementType.PREPARED;
        ResultSetType resultSetType = ResultSetType.FORWARD_ONLY;
        SqlCommandType sqlCommandType = sqlsourceBuilder.getSqlCommandType();
        boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
        boolean flushCache = !isSelect;
        boolean useCache = isSelect;
        Options options = method.getAnnotation(Options.class);

        KeyGenerator keyGenerator;
        String keyProperty = "id";
        String keyColumn = null;
        if (SqlCommandType.INSERT.equals(sqlCommandType) || SqlCommandType.UPDATE.equals(sqlCommandType)) {
            // first check for SelectKey annotation - that overrides everything
            // else
            SelectKey selectKey = method.getAnnotation(SelectKey.class);
            if (selectKey != null) {
                keyGenerator = handleSelectKeyAnnotation(selectKey, mappedStatementId, getParameterType(method),
                        languageDriver);
                keyProperty = selectKey.keyProperty();
            } else {
                if (options == null) {
                    keyGenerator = configuration.isUseGeneratedKeys() ? new Jdbc3KeyGenerator() : new NoKeyGenerator();
                } else {
                    keyGenerator = options.useGeneratedKeys() ? new Jdbc3KeyGenerator() : new NoKeyGenerator();
                    keyProperty = options.keyProperty();
                    keyColumn = options.keyColumn();
                }
            }
        } else {
            keyGenerator = new NoKeyGenerator();
        }

        if (options != null) {
            if (FlushCachePolicy.TRUE.equals(options.flushCache())) {
                flushCache = true;
            } else if (FlushCachePolicy.FALSE.equals(options.flushCache())) {
                flushCache = false;
            }
            useCache = options.useCache();
            fetchSize = options.fetchSize() > -1 || options.fetchSize() == Integer.MIN_VALUE ? options.fetchSize()
                    : null; // issue #348
            timeout = options.timeout() > -1 ? options.timeout() : null;
            resultSetType = options.resultSetType();
        }

        String resultMapId = null;
        ResultMap resultMapAnnotation = method.getAnnotation(ResultMap.class);
        if (resultMapAnnotation != null) {
            String[] resultMaps = resultMapAnnotation.value();
            StringBuilder sb = new StringBuilder();
            for (String resultMap : resultMaps) {
                if (sb.length() > 0)
                    sb.append(",");
                sb.append(resultMap);
            }
            resultMapId = sb.toString();
        } else if (isSelect) {
            resultMapId = parseResultMap(method);
        }

        assistant.addMappedStatement(mappedStatementId, sqlsourceBuilder, statementType, sqlCommandType, fetchSize,
                timeout, null, parameterTypeClass, resultMapId, getReturnType(method), resultSetType, flushCache,
                useCache, false, keyGenerator, keyProperty, keyColumn, null, languageDriver, null);
    }

    private Class<?> getReturnType(Method method) {
      Class<?> returnType = method.getReturnType();
      Type resolvedReturnType = TypeParameterResolver.resolveReturnType(method, type);
      if (resolvedReturnType instanceof Class) {
        returnType = (Class<?>) resolvedReturnType;
        if (returnType.isArray()) {
          returnType = returnType.getComponentType();
        }
        // gcode issue #508
        if (void.class.equals(returnType)) {
          ResultType rt = method.getAnnotation(ResultType.class);
          if (rt != null) {
            returnType = rt.value();
          }
        }
      } else if (resolvedReturnType instanceof ParameterizedType) {
        ParameterizedType parameterizedType = (ParameterizedType) resolvedReturnType;
        Class<?> rawType = (Class<?>) parameterizedType.getRawType();
        if (Collection.class.isAssignableFrom(rawType) || Cursor.class.isAssignableFrom(rawType)) {
          Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
          if (actualTypeArguments != null && actualTypeArguments.length == 1) {
            Type returnTypeParameter = actualTypeArguments[0];
            if (returnTypeParameter instanceof Class<?>) {
              returnType = (Class<?>) returnTypeParameter;
            } else if (returnTypeParameter instanceof ParameterizedType) {
              // (gcode issue #443) actual type can be a also a parameterized type
              returnType = (Class<?>) ((ParameterizedType) returnTypeParameter).getRawType();
            } else if (returnTypeParameter instanceof GenericArrayType) {
              Class<?> componentType = (Class<?>) ((GenericArrayType) returnTypeParameter).getGenericComponentType();
              // (gcode issue #525) support List<byte[]>
              returnType = Array.newInstance(componentType, 0).getClass();
            }
          }
        } else if (method.isAnnotationPresent(MapKey.class) && Map.class.isAssignableFrom(rawType)) {
          // (gcode issue 504) Do not look into Maps if there is not MapKey annotation
          Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
            if (actualTypeArguments != null && actualTypeArguments.length == 2) {
              Type returnTypeParameter = actualTypeArguments[1];
              if (returnTypeParameter instanceof Class<?>) {
                returnType = (Class<?>) returnTypeParameter;
              } else if (returnTypeParameter instanceof ParameterizedType) {
                // (gcode issue 443) actual type can be a also a parameterized type
                returnType = (Class<?>) ((ParameterizedType) returnTypeParameter).getRawType();
              }
            }
        }
      }

      return returnType;
    }
    
    private LanguageDriver getLanguageDriver(Method method) {
      Lang lang = method.getAnnotation(Lang.class);
      Class<?> langClass = null;
      if (lang != null) {
        langClass = lang.value();
      }
      return assistant.getLanguageDriver(langClass);
    }

    private Class<?> getParameterType(Method method) {
      Class<?> parameterType = null;
      Class<?>[] parameterTypes = method.getParameterTypes();
      for (Class<?> currentParameterType : parameterTypes) {
        if (!RowBounds.class.isAssignableFrom(currentParameterType) && !ResultHandler.class.isAssignableFrom(currentParameterType)) {
          if (parameterType == null) {
            parameterType = currentParameterType;
          } else {
            // issue #135
            parameterType = ParamMap.class;
          }
        }
      }
      return parameterType;
    }

    private String parseResultMap(Method method) {
      Class<?> returnType = getReturnType(method);
      ConstructorArgs args = method.getAnnotation(ConstructorArgs.class);
      Results results = method.getAnnotation(Results.class);
      TypeDiscriminator typeDiscriminator = method.getAnnotation(TypeDiscriminator.class);
      String resultMapId = generateResultMapName(method);
      applyResultMap(resultMapId, returnType, argsIf(args), resultsIf(results), typeDiscriminator);
      return resultMapId;
    }

    private String generateResultMapName(Method method) {
      Results results = method.getAnnotation(Results.class);
      if (results != null && !results.id().isEmpty()) {
        return type.getName() + "." + results.id();
      }
      StringBuilder suffix = new StringBuilder();
      for (Class<?> c : method.getParameterTypes()) {
        suffix.append("-");
        suffix.append(c.getSimpleName());
      }
      if (suffix.length() < 1) {
        suffix.append("-void");
      }
      return type.getName() + "." + method.getName() + suffix;
    }

    private void applyResultMap(String resultMapId, Class<?> returnType, Arg[] args, Result[] results, TypeDiscriminator discriminator) {
      List<ResultMapping> resultMappings = new ArrayList<ResultMapping>();
      applyConstructorArgs(args, returnType, resultMappings);
      applyResults(results, returnType, resultMappings);
      Discriminator disc = applyDiscriminator(resultMapId, returnType, discriminator);
      // TODO add AutoMappingBehaviour
      assistant.addResultMap(resultMapId, returnType, null, disc, resultMappings, null);
      createDiscriminatorResultMaps(resultMapId, returnType, discriminator);
    }

    private void createDiscriminatorResultMaps(String resultMapId, Class<?> resultType, TypeDiscriminator discriminator) {
      if (discriminator != null) {
        for (Case c : discriminator.cases()) {
          String caseResultMapId = resultMapId + "-" + c.value();
          List<ResultMapping> resultMappings = new ArrayList<ResultMapping>();
          // issue #136
          applyConstructorArgs(c.constructArgs(), resultType, resultMappings);
          applyResults(c.results(), resultType, resultMappings);
          // TODO add AutoMappingBehaviour
          assistant.addResultMap(caseResultMapId, c.type(), resultMapId, null, resultMappings, null);
        }
      }
    }

    private void applyConstructorArgs(Arg[] args, Class<?> resultType, List<ResultMapping> resultMappings) {
      for (Arg arg : args) {
        List<ResultFlag> flags = new ArrayList<ResultFlag>();
        flags.add(ResultFlag.CONSTRUCTOR);
        if (arg.id()) {
          flags.add(ResultFlag.ID);
        }
        @SuppressWarnings("unchecked")
        Class<? extends TypeHandler<?>> typeHandler = (Class<? extends TypeHandler<?>>)
                (arg.typeHandler() == UnknownTypeHandler.class ? null : arg.typeHandler());
        ResultMapping resultMapping = assistant.buildResultMapping(
            resultType,
            nullOrEmpty(arg.name()),
            nullOrEmpty(arg.column()),
            arg.javaType() == void.class ? null : arg.javaType(),
            arg.jdbcType() == JdbcType.UNDEFINED ? null : arg.jdbcType(),
            nullOrEmpty(arg.select()),
            nullOrEmpty(arg.resultMap()),
            null,
            null,
            typeHandler,
            flags,
            null,
            null,
            false);
        resultMappings.add(resultMapping);
      }
    }

    private String nullOrEmpty(String value) {
        return value == null || value.trim().length() == 0 ? null : value;
    }

    private Result[] resultsIf(Results results) {
        return results == null ? new Result[0] : results.value();
    }

    private Arg[] argsIf(ConstructorArgs args) {
        return args == null ? new Arg[0] : args.value();
    }

    private void applyResults(Result[] results, Class<?> resultType, List<ResultMapping> resultMappings) {
      for (Result result : results) {
        List<ResultFlag> flags = new ArrayList<ResultFlag>();
        if (result.id()) {
          flags.add(ResultFlag.ID);
        }
        @SuppressWarnings("unchecked")
        Class<? extends TypeHandler<?>> typeHandler = (Class<? extends TypeHandler<?>>)
                ((result.typeHandler() == UnknownTypeHandler.class) ? null : result.typeHandler());
        ResultMapping resultMapping = assistant.buildResultMapping(
            resultType,
            nullOrEmpty(result.property()),
            nullOrEmpty(result.column()),
            result.javaType() == void.class ? null : result.javaType(),
            result.jdbcType() == JdbcType.UNDEFINED ? null : result.jdbcType(),
            hasNestedSelect(result) ? nestedSelectId(result) : null,
            null,
            null,
            null,
            typeHandler,
            flags,
            null,
            null,
            isLazy(result));
        resultMappings.add(resultMapping);
      }
    }
    
    private String nestedSelectId(Result result) {
      String nestedSelect = result.one().select();
      if (nestedSelect.length() < 1) {
        nestedSelect = result.many().select();
      }
      if (!nestedSelect.contains(".")) {
        nestedSelect = type.getName() + "." + nestedSelect;
      }
      return nestedSelect;
    }

    private boolean isLazy(Result result) {
        Boolean isLazy = configuration.isLazyLoadingEnabled();
        if (result.one().select().length() > 0 && FetchType.DEFAULT != result.one().fetchType()) {
            isLazy = (result.one().fetchType() == FetchType.LAZY);
        } else if (result.many().select().length() > 0 && FetchType.DEFAULT != result.many().fetchType()) {
            isLazy = (result.many().fetchType() == FetchType.LAZY);
        }
        return isLazy;
    }

    private boolean hasNestedSelect(Result result) {
        if (result.one().select().length() > 0 && result.many().select().length() > 0) {
            throw new BuilderException("Cannot use both @One and @Many annotations in the same @Result");
        }
        return result.one().select().length() > 0 || result.many().select().length() > 0;
    }

    private Discriminator applyDiscriminator(String resultMapId, Class<?> resultType, TypeDiscriminator discriminator) {
      if (discriminator != null) {
        String column = discriminator.column();
        Class<?> javaType = discriminator.javaType() == void.class ? String.class : discriminator.javaType();
        JdbcType jdbcType = discriminator.jdbcType() == JdbcType.UNDEFINED ? null : discriminator.jdbcType();
        @SuppressWarnings("unchecked")
        Class<? extends TypeHandler<?>> typeHandler = (Class<? extends TypeHandler<?>>)
                (discriminator.typeHandler() == UnknownTypeHandler.class ? null : discriminator.typeHandler());
        Case[] cases = discriminator.cases();
        Map<String, String> discriminatorMap = new HashMap<String, String>();
        for (Case c : cases) {
          String value = c.value();
          String caseResultMapId = resultMapId + "-" + value;
          discriminatorMap.put(value, caseResultMapId);
        }
        return assistant.buildDiscriminator(resultType, column, javaType, jdbcType, typeHandler, discriminatorMap);
      }
      return null;
    }

    private KeyGenerator handleSelectKeyAnnotation(SelectKey selectKeyAnnotation, String baseStatementId, Class<?> parameterTypeClass, LanguageDriver languageDriver) {
      String id = baseStatementId + SelectKeyGenerator.SELECT_KEY_SUFFIX;
      Class<?> resultTypeClass = selectKeyAnnotation.resultType();
      StatementType statementType = selectKeyAnnotation.statementType();
      String keyProperty = selectKeyAnnotation.keyProperty();
      String keyColumn = selectKeyAnnotation.keyColumn();
      boolean executeBefore = selectKeyAnnotation.before();

      // defaults
      boolean useCache = false;
      KeyGenerator keyGenerator = NoKeyGenerator.INSTANCE;
      Integer fetchSize = null;
      Integer timeout = null;
      boolean flushCache = false;
      String parameterMap = null;
      String resultMap = null;
      ResultSetType resultSetTypeEnum = null;

      SqlSource sqlSource = buildSqlSourceFromStrings(selectKeyAnnotation.statement(), parameterTypeClass, languageDriver);
      SqlCommandType sqlCommandType = SqlCommandType.SELECT;

      assistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType, fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass, resultSetTypeEnum,
          flushCache, useCache, false,
          keyGenerator, keyProperty, keyColumn, null, languageDriver, null);

      id = assistant.applyCurrentNamespace(id, false);

      MappedStatement keyStatement = configuration.getMappedStatement(id, false);
      SelectKeyGenerator answer = new SelectKeyGenerator(keyStatement, executeBefore);
      configuration.addKeyGenerator(id, answer);
      return answer;
    }

    private SqlSource buildSqlSourceFromStrings(String[] strings, Class<?> parameterTypeClass, LanguageDriver languageDriver) {
      final StringBuilder sql = new StringBuilder();
      for (String fragment : strings) {
        sql.append(fragment);
        sql.append(" ");
      }
      return languageDriver.createSqlSource(configuration, sql.toString().trim(), parameterTypeClass);
    }
}
